Ас­сем­блер пре­дос­тавля­ет прак­тичес­ки неог­раничен­ную сво­боду для самовы­раже­ния и все­воз­можных извра­щений, что выгод­но отли­чает его от язы­ков высоко­го уров­ня. Вот мы и вос­поль­зуем­ся этой воз­можностью, извра­тив­шись не по‑дет­ски и сот­ворив со сте­ком то, о чем прип­люсну­тый Си толь­ко меч­тает.
 

Турбопередача стековых аргументов

Пе­реда­чу аргу­мен­тов через стек мож­но сущес­твен­но уско­рить, в слу­чае если аргу­мен­ты пред­став­ляют собой кон­стан­ту, извес­тную еще на ста­дии тран­сля­ции. Клас­сичес­кий спо­соб переда­чи выг­лядит так:

Клас­сичес­кий спо­соб переда­чи сте­ковых аргу­мен­тов
00000000: 6869060000 push 000000669
00000005: 6899090000 push 000000999
0000000A: 6896060000 push 000000696
0000000F: E852060000 call 000000666

До­воль­но рас­точитель­ное (в пла­не про­цес­сорных так­тов) решение, осо­бен­но если фун­кция вызыва­ется мно­гок­ратно. При этом опе­ран­ды коман­ды PUSH перего­няют­ся из сек­ции .text (находя­щей­ся в кодовой кэш‑памяти пер­вого уров­ня) в область сте­ка, находя­щуюся в кэш‑памяти дан­ных. Ну и зачем гонять их туда и обратно, ког­да аргу­мен­ты мож­но исполь­зовать непос­редс­твен­но по мес­ту хра­нения?

Усо­вер­шенс­тво­ван­ный при­мер выг­лядит так:

.code
MOV EBP, ESP
MOV ESP, offset func_arg + 4
CALL my_func
MOV ESP, EBP
.data
func_arg DD 00h, 696h, 999h, 669h

И хотя раз­мер кода пос­ле опти­миза­ции не толь­ко не сок­ратил­ся, но даже уве­личил­ся (14h байт до опти­миза­ции и 1Eh - пос­ле), мы сох­ранили нем­ного сте­ковой памяти и сок­ратили вре­мя выпол­нения. При­чем чем боль­ше аргу­мен­тов переда­ется фун­кции, тем в более выиг­рышном положе­нии ока­зыва­ется опти­мизи­рован­ный вари­ант, пос­коль­ку неоп­тимизи­рован­ный вынуж­ден тра­тить на каж­дый аргу­мент один допол­нитель­ный байт!

00000000: 8BEC mov ebp, esp
00000002: BC66000000 mov esp, 000000013
00000007: E80E000000 call 000000666
0000000C: 8BE5 mov esp, ebp
…
0000000E: 00 00 00 00 96 06 00 00 ? 99 09 00 00 69 06 00 00
0000001E:

Нес­коль­ко замеча­ний по поводу. Пер­вое. Опе­раци­онные сис­темы семей­ства Windows NT (к которым при­над­лежат Windows 2000, Windows XP, Windows Vista, Windows Server 2003 и Windows Server Longhorn) гаран­тиру­ют целос­тность содер­жимого сте­ка выше его вер­шины (для адре­сов мень­ших, чем ESP), поэто­му перено­сят такие извра­щения безо вся­кого ущер­ба для работос­пособ­ности прог­раммы. Опе­раци­онные сис­темы семей­ства Windows 9x ведут себя ина­че, бес­церемон­но исполь­зуя все, что находит­ся выше ESP в целях «про­изводс­твен­ной необ­ходимос­ти», что ведет к иска­жению сек­ции дан­ных и пос­леду­юще­му кра­ху прог­раммы. Поэто­му все, что было ска­зано здесь, рас­простра­няет­ся толь­ко на NT.

Передача стековых аргументов напрямую, без их фактической засылки в стек
Пе­реда­ча сте­ковых аргу­мен­тов нап­рямую, без их фак­тичес­кой засыл­ки в стек

За­меча­ние номер два. Перед аргу­мен­тами необ­ходимо оста­вить двой­ное сло­во (а в 64-бит­ном режиме — чет­вер­тное) для сох­ранения адре­са воз­вра­та. При этом сек­ция дан­ных, где находит­ся это сло­во, дол­жна быть дос­тупна на запись. Если же фун­кция вызыва­ется из одно­го единс­твен­ного мес­та и адрес воз­вра­та известен заранее, ничего не меша­ет положить его рядом с аргу­мен­тами. Но тог­да фун­кцию при­дет­ся пус­кать коман­дой jump, а не call, что еще боль­ше уве­личи­вает про­изво­дитель­ность:

Вы­зов фун­кции с пре­доп­ределен­ным адре­сом воз­вра­та коман­дой JMP
.code
MOV EBP, ESP
MOV ESP, offset func_arg + 4
JMP my_func
here:
MOV ESP, EBP
.data
func_arg DD offset here, 696h, 999h, 669h

Кста­ти говоря, ни адрес воз­вра­та, ни аргу­мен­ты фун­кции вов­се не обя­заны быть кон­стан­тами, извес­тны­ми на ста­дии ком­пиляции, и они могут сво­бод­но модифи­циро­вать­ся в любой момент коман­дами MOV и STOS. Так­же если аргу­мен­ты хра­нят­ся в локаль­ных перемен­ных, то засылать их в стек не обя­затель­но! Дос­таточ­но лишь скор­ректи­ровать регистр ESP таким обра­зом, что­бы перемен­ные‑аргу­мен­ты ока­зались на вер­шине. Естес­твен­но, порядок раз­мещения аргу­мен­тов в памяти дол­жен сов­падать с поряд­ком переда­чи аргу­мен­тов, но на ассем­бле­ре, в отли­чие от язы­ков высоко­го уров­ня, мы можем самос­тоятель­но выбирать нуж­ную схе­му раз­мещения перемен­ных, так что это не проб­лема.

Еще одна тон­кость: «опти­мизи­рован­ный» вари­ант обла­дает все­ми фор­маль­ными атри­бута­ми «переда­чи по зна­чению», но де‑фак­то аргу­мен­ты переда­ются по ссыл­ке. То есть сов­сем наобо­рот! Аргу­мен­ты переда­ются по зна­чению, но это зна­чение пос­ле выхода из фун­кции сох­раня­ет свое сос­тояние, ведет себя так, как буд­то бы оно было переда­но по ссыл­ке. Иног­да это эко­номит так­ты про­цес­сора и сок­раща­ет пот­ребнос­ти в памяти, но иног­да ведет к труд­ноуло­вимым ошиб­кам, лиш­ний раз под­тверждая тезис, что нет в мире совер­шенс­тва.

И пос­леднее: при всех этих играх со сте­ком сле­дует пом­нить, что целый ряд API-фун­кций тре­бует, что­бы ука­затель сте­ка был выров­нен на гра­ницу четырех бай­тов. Наруше­ние это­го пра­вила ведет к неп­ред­ска­зуемым пос­ледс­тви­ям.

 

Повторное использование кадра стека

При вхо­де внутрь фун­кции боль­шое количес­тво локаль­ных перемен­ных ини­циали­зиру­ется кон­стан­тами или зна­чени­ями, инва­риан­тны­ми по отно­шению к самой фун­кции (то есть дру­гими перемен­ными, чаще все­го гло­баль­ными). При­чем ини­циали­зация обыч­но осу­щест­вля­ется коман­дой MOV, а для обслу­жива­ния стро­ковых перемен­ных при­ходит­ся при­бегать к REP MOVSB. Все это мед­ленно, гро­моз­дко и неп­роиз­водитель­но.

А почему бы не под­готовить кадр сте­ка еще на ста­дии тран­сля­ции?! В гру­бом приб­лижении это будет выг­лядеть так:

Вы­зов фун­кции с заранее под­готов­ленны­ми аргу­мен­тами и локаль­ными перемен­ными
.code
MOV EBP, ESP
MOV ESP, offset func_arg
JMP my_func
MOV ESP, EBP
…
my_func:
MOV EBP,ESP
SUB ESP, offset func_locals - offset return_address
…
…
…
MOV ESP,EBP
RETN
.data
func_locals:
var_1 DB 66h
var_2 DD offset globalFlag
var_s DB "hello",0
var_x DD 0
var_y DD 0
return_address:
DD 00h
func_args:
DD 696h, 999h, 669h

В некото­рых слу­чаях дос­тига­ется прос­то колос­саль­ное уско­рение, одна­ко тут есть один под­водный камень — при пов­торном вызове фун­кции все «ини­циали­зиро­ван­ные» перемен­ные сох­раня­ют свои текущие зна­чения и нас­тупа­ет пол­ный облом. Фак­тичес­ки мы добились того, что прев­ратили локаль­ные сте­ковые перемен­ные в ста­тичес­кие! Бес­спор­но, иног­да это очень хорошо, но в 90% слу­чаев нам нуж­но сов­сем дру­гое. Вот и устро­им себе это дру­гое с помощью REP MOVS! Под­готав­лива­ем ини­циали­зиро­ван­ные локаль­ные перемен­ные на ста­дии соз­дания ассем­блер­ной прог­раммы, а затем копиру­ем их в кадр фун­кции при его откры­тии. Это нам­ного быс­трее, чем ини­циали­зиро­вать каж­дую локаль­ную перемен­ную по отдель­нос­ти коман­дой MOV.

К тому же кад­ры некото­рых фун­кций дос­таточ­но схо­жи меж­ду собой, что поз­воля­ет объ­еди­нить нес­коль­ко кад­ров в один! Дос­таточ­но ска­зать, что каж­дая фун­кция нуж­дает­ся в перемен­ных, ини­циали­зиро­ван­ных нулями. Что­бы не делать мно­го раз один и тот же MOV [EBP+XXh],0 луч­ше (и быс­трее) выпол­нить REP STOS!

Вот в чем истинная сила ассем­бле­ра! Вот извра­щения, недос­тупные язы­кам высоко­го уров­ня, но… самые звер­ские изде­ватель­ства еще впе­реди!!!

 

Защита адреса возврата от переполнения

Проб­лема перепол­няющих­ся буферов породи­ла огромное количес­тво чер­вей, открыв без­гра­нич­ный прос­тор для хакер­ских атак. Но, нес­мотря на все ухищ­рения, пред­при­нятые как со сто­роны про­изво­дите­лей ком­пилято­ров, так и со сто­роны раз­работ­чиков опе­раци­онных сис­тем, она оста­ется нерешен­ной и по сей день.

Ас­сем­блер пре­дос­тавля­ет по мень­шей мере 2 надеж­ных механиз­ма, до которых ком­пилято­ры еще не «додума­лись». Пер­вый и самый прос­той — это 2 сте­ка: один для хра­нения адре­сов воз­вра­та, дру­гой - для переда­чи аргу­мен­тов и локаль­ных перемен­ных. Кста­ти говоря, сущес­тву­ют про­цес­сорные архи­тек­туры, в которых этот механизм реали­зован изна­чаль­но. Но x86-семей­ство к ним, увы, не отно­сит­ся, поэто­му при­ходит­ся брать в лапы напиль­ник и точить.

Для орга­низа­ции двух раз­дель­ных сте­ков нам тре­бует­ся все­го лишь 1 допол­нитель­ный регистр (который мож­но выделить из пула регис­тров обще­го наз­начения). Пусть это будет регистр EBP, ука­зыва­ющий на стек с локаль­ными перемен­ными. Собс­твен­но говоря, неп­равиль­но называть его сте­ком, пос­коль­ку в опе­раци­онных сис­темах семей­ства Windows стек пред­став­ляет собой осо­бый реги­он памяти, под­пира­емый свер­ху сто­роже­вой стра­ницей page-guard. Мы же раз­местим свой стек в памяти, получен­ной фун­кци­ей VirtualAlloc или, если хочет­ся опти­миза­ции, в .BSS-сек­ции PE-фай­ла, выделе­ние которой обхо­дит­ся очень дешево­го (в пла­не машин­ного вре­мени). Но это все детали реали­зации. Будем счи­тать, что ESP ука­зыва­ет на нор­маль­ный стек, а EBP — на рукот­ворный. Как тог­да будет про­исхо­дить вызов фун­кций и переда­ча аргу­мен­тов?

А вот так:

; // подготовительные операции
MOV EBP, [XXX] ; XXX - указатель на рукотворный стек
MOV ESP, ESP ; ;-)
…
; // передача аргументов функции
MOV [EBP+00h], arg_a
MOV [EBP+04h], arg_b
MOV [EBP+08h], arg_c
// вызов самой функции
CALL func
…
// ================================================================================
; // реализация самой функции
func:
ADD EBP, local_var_size ; резервируем память под локальные переменные
MOV ECX, [EBP-local_var_size+04h] ; загрузка аргумента arg_b в регистр ECX
MOV ESI, [EBP-local_var_size+08h] ; загрузка аргумента arg_c в регистр ESI
MOV EDI, EBP ; грузим в EDI указатель на конец области локальных переменных
SUB EDI, local_var_size ; вычисляем указатель на локальный буфер
; (в данном случае он расположен по смещению 00h
; относительно фрейма)
REP MOVSB ; копируем arg_b байтов из arg_c в локальный буфер
; // делаем еще что-то полезное
RET ; выходим из функции

Ру­кот­ворный стек с локаль­ными перемен­ными и аргу­мен­тами рас­тет свер­ху вниз, то есть в нап­равле­нии, про­тиво­полож­ном рос­ту обыч­ного сте­ка, и это нес­прос­та. Во‑пер­вых, под­систе­ма памяти IBM PC и опе­раци­онная сис­тема Windows опти­мизи­рова­ны имен­но под такое выделе­ние памяти и мы получа­ем выиг­рыш в про­изво­дитель­нос­ти. Во‑вто­рых, вни­зу рукот­ворно­го сте­ка находит­ся неини­циали­зиро­ван­ная область памяти, что дела­ет ошиб­ки перепол­нения неак­туаль­ными. Затира­ются лишь локаль­ные перемен­ные текущей фун­кции, да и то лишь те, которые лежат ниже перепол­няюще­гося буфера.

Ад­реса воз­вра­та хра­нят­ся в дру­гом мес­те и на них эти перепол­нения не рас­простра­няют­ся, если, конеч­но, натураль­ный стек рас­положен выше рукот­ворно­го.

Ос­новную труд­ность пред­став­ляет засыл­ка аргу­мен­тов в рукот­ворный стек. Под MS-DOS мы мог­ли выделить отдель­ный сег­мент и исполь­зовать PUSH с пре­фик­сом «GS:», а под Windows при­ходит­ся при­менять MOV [EBP+XXh], YYYY. При этом адре­сации типа «память – память» в x86-про­цес­сорах не было и нет. В прак­тичес­ком пла­не это озна­чает, что нам при­дет­ся исполь­зовать про­межу­точ­ные регис­тры: MOV EAX, [YYYY]/MOV [EBP+XXh], EAX. Впро­чем, это мож­но опти­мизи­ровать, если исполь­зовать коман­ду STOSD, занима­ющую в «машин­ном пред­став­лении» все­го один байт и копиру­ющую содер­жимое EAX в ячей­ку, на которую ука­зыва­ет EDI, одновре­мен­но с уве­личе­нием пос­ледне­го на раз­мер двой­ного сло­ва. Стас­кивать аргу­мен­ты с рукот­ворно­го сте­ка мож­но коман­дой LODSD.

Окон­чатель­но рас­хулига­нив­шись, мож­но соз­дать целых 3 сте­ка: один - стан­дар­тный, для хра­нения адре­сов воз­вра­та, дру­гой — для аргу­мен­тов и тре­тий - для локаль­ных перемен­ных. Что­бы не рас­ходовать регис­тры понап­расну, мож­но хра­нить ука­зате­ли на вер­шины двух рукот­ворных сте­ков в опе­ратив­ной памяти, заг­ружая их то в регистр EBP, то в ESI/EDI, в зависи­мос­ти от того, какой из них ока­жет­ся удоб­нее в тот или иной момент. Падения про­изво­дитель­нос­ти мож­но не опа­сать­ся. Боль­шую часть сво­его вре­мени ука­зате­ли будут про­водить в кэш‑памяти, извле­каясь все­го за 1-2 так­та.

Ес­тес­твен­но, все ска­зан­ное выше, отно­сит­ся толь­ко к нашим собс­твен­ным фун­кци­ям, а API-фун­кции опе­раци­онной сис­темы таких извра­щений не понима­ют и ожи­дают аргу­мен­тов в стан­дар­тном сте­ке. Ну, что тут мож­но ска­зать… Пер­сональ­но для API-фун­кций аргу­мен­ты мож­но передать и в стан­дар­тном сте­ке, пред­варитель­но убе­див­шись, что при этих аргу­мен­тах фун­кция гаран­тирован­но не вызовет перепол­нения (что вов­се не факт, осо­бен­но при работе с фун­кци­ями из биб­лиоте­ки mshtml.dll). К тому же в 64-бит­ной редак­ции Windows аргу­мен­ты API-фун­кци­ями в боль­шинс­тве слу­чаев переда­ются не через стек, а через регис­тры, поэто­му опи­сан­ная методи­ка к ним впол­не при­мени­ма.

А вот как защитить от перепол­нения фун­кции обыч­ных биб­лиотек? Самое прос­тое решение — выз­вать фун­кции не по CALL, а по JMP, раз­местив адрес воз­вра­та на вер­шине стра­ницы памяти, дос­тупной толь­ко на чте­ние. Ниже ее будут толь­ко аргу­мен­ты, так­же дос­тупные толь­ко на чте­ние, а вот локаль­ные перемен­ные, соз­дава­емые фун­кци­ей, будут дос­тупны и на чте­ние, и на запись. Естес­твен­но, этот трюк будет работать толь­ко с теми фун­кци­ями, которые не изме­няют сво­их аргу­мен­тов (а мно­гие из них изме­няют их толь­ко так), но по‑дру­гому прос­то не получа­ется!

Оцени статью:

Что тебе понравилось больше всего?
Что тебе не понравилось больше всего?